package im.composer.audio;

import java.io.File;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.URI;
import java.net.URL;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.Collections;
import java.util.LinkedList;
import java.util.List;
import java.util.NavigableMap;
import java.util.Objects;
import java.util.Optional;
import java.util.ServiceLoader;
import java.util.TreeMap;
import java.util.stream.Collectors;
import java.util.stream.Stream;

import javax.sound.sampled.AudioFileFormat;
import javax.sound.sampled.AudioFileFormat.Type;
import javax.sound.sampled.AudioFormat;
import javax.sound.sampled.AudioInputStream;
import javax.sound.sampled.UnsupportedAudioFileException;
import javax.sound.sampled.spi.AudioFileReader;
import javax.sound.sampled.spi.AudioFileWriter;
import javax.sound.sampled.spi.FormatConversionProvider;

import org.jaudiolibs.audioservers.AudioClient;
import org.jaudiolibs.audioservers.AudioConfiguration;
import org.jaudiolibs.audioservers.AudioServer;
import org.jaudiolibs.audioservers.AudioServerProvider;
import org.tritonus.dsp.interfaces.FloatSampleWriter;
import org.tritonus.share.sampled.FloatInputStream;
import org.tritonus.share.sampled.FloatSampleInput;
import org.tritonus.share.sampled.file.AudioOutputStream;
import org.tritonus.share.sampled.file.TNonSeekableDataOutputStream;
import org.tritonus.share.sampled.file.TSeekableDataOutputStream;

import im.composer.audio.outputstream.AudioOutputStreamBuilder;
import im.composer.io.MyFiles;
import im.composer.io.RandomPathWriter;
import im.composer.media.sound.codec.FloatOutputStream;
import im.composer.media.sound.codec.PathAwareAudioFileReader;

/**
 * 
 * @author David Zhang (zdl@zdl.hk)
 * @since 2016-09-20
 *
 */
public class AudioSystem {

	/**
	 * An integer that stands for an unknown numeric value. This value is appropriate only for signed quantities that do not normally take negative values. Examples include file sizes, frame sizes,
	 * buffer sizes, and sample rates. A number of Java Sound constructors accept a value of <code>NOT_SPECIFIED</code> for such parameters. Other methods may also accept or return this value, as
	 * documented.
	 */
	public static final int NOT_SPECIFIED = -1;

	/**
	 * Private no-args constructor for ensuring against instantiation.
	 */
	private AudioSystem() {
	}

	/**
	 * Obtains the audio file format of the provided input stream. The stream must point to valid audio file data. The implementation of this method may require multiple parsers to examine the stream
	 * to determine whether they support it. These parsers must be able to mark the stream, read enough data to determine whether they support the stream, and, if not, reset the stream's read pointer
	 * to its original position. If the input stream does not support these operations, this method may fail with an <code>IOException</code>.
	 * 
	 * @param stream
	 *            the input stream from which file format information should be extracted
	 * @return an <code>AudioFileFormat</code> object describing the stream's audio file format
	 * @throws UnsupportedAudioFileException
	 *             if the stream does not point to valid audio file data recognized by the system
	 * @throws IOException
	 *             if an input/output exception occurs
	 * @see InputStream#markSupported
	 * @see InputStream#mark
	 */
	public static AudioFileFormat getAudioFileFormat(InputStream stream) throws UnsupportedAudioFileException, IOException {
		Optional<AudioFileFormat> opt = reader_list.stream().sequential().map(reader -> {
			try {
				return reader.getAudioFileFormat(stream);
			} catch (UnsupportedAudioFileException | IOException e) {
				return null;
			}
		}).filter(o -> o != null).findFirst();

		if (opt.isPresent()) {
			return opt.get();
		} else {
			throw new UnsupportedAudioFileException("file is not a supported file type");
		}
	}

	/**
	 * Obtains the audio file format of the specified URL. The URL must point to valid audio file data.
	 * 
	 * @param url
	 *            the URL from which file format information should be extracted
	 * @return an <code>AudioFileFormat</code> object describing the audio file format
	 * @throws UnsupportedAudioFileException
	 *             if the URL does not point to valid audio file data recognized by the system
	 * @throws IOException
	 *             if an input/output exception occurs
	 */
	public static AudioFileFormat getAudioFileFormat(URL url) throws UnsupportedAudioFileException, IOException {
		Optional<AudioFileFormat> opt = reader_list.stream().sequential().map(reader -> {
			try {
				return reader.getAudioFileFormat(url);
			} catch (UnsupportedAudioFileException | IOException e) {
				return null;
			}
		}).filter(o -> o != null).findFirst();

		if (opt.isPresent()) {
			return opt.get();
		} else {
			throw new UnsupportedAudioFileException("file is not a supported file type");
		}
	}

	/**
	 * Obtains the audio file format of the specified <code>File</code>. The <code>File</code> must point to valid audio file data.
	 * 
	 * @param file
	 *            the <code>File</code> from which file format information should be extracted
	 * @return an <code>AudioFileFormat</code> object describing the audio file format
	 * @throws UnsupportedAudioFileException
	 *             if the <code>File</code> does not point to valid audio file data recognized by the system
	 * @throws IOException
	 *             if an I/O exception occurs
	 */
	public static AudioFileFormat getAudioFileFormat(File file) throws UnsupportedAudioFileException, IOException {
		if (!file.exists()) {
			throw new FileNotFoundException();
		} else if (!file.canRead()) {
			throw new IOException();
		}

		Optional<AudioFileFormat> opt = reader_list.stream().sequential().map(reader -> {
			try {
				return reader.getAudioFileFormat(file);
			} catch (UnsupportedAudioFileException | IOException e) {
				return null;
			}
		}).filter(o -> o != null).findFirst();

		if (opt.isPresent()) {
			return opt.get();
		} else {
			throw new UnsupportedAudioFileException("file is not a supported file type");
		}
	}

	public static AudioFileFormat getAudioFileFormat(Path path) throws UnsupportedAudioFileException, IOException {
		Optional<AudioFileFormat> opt = reader_list.stream().filter(reader -> reader instanceof PathAwareAudioFileReader).map(reader -> (PathAwareAudioFileReader) reader).sequential().map(reader -> {
			try {
				return reader.getAudioFileFormat(path);
			} catch (UnsupportedAudioFileException | IOException e) {
				return null;
			}
		}).filter(o -> o != null).findFirst();
		if(opt.isPresent()){
			return opt.get();
		}else{
			return getAudioFileFormatFallback(path);
		}
	}

	private static AudioFileFormat getAudioFileFormatFallback(Path path) throws UnsupportedAudioFileException, IOException {
		if (path.toUri().getScheme().equalsIgnoreCase("file")) {
			return getAudioFileFormat(path.toFile());
		}
		if (Files.notExists(path) || !Files.isRegularFile(path)) {
			throw new FileNotFoundException();
		}
		if (!Files.isReadable(path)) {
			throw new IOException("Path " + path + " is NOT readable!");
		}
		InputStream in = MyFiles.getEnhancedInputStream(path);
		return getAudioFileFormat(in);
	}

	/**
	 * Obtains an audio input stream from the provided input stream. The stream must point to valid audio file data. The implementation of this method may require multiple parsers to examine the
	 * stream to determine whether they support it. These parsers must be able to mark the stream, read enough data to determine whether they support the stream, and, if not, reset the stream's read
	 * pointer to its original position. If the input stream does not support these operation, this method may fail with an <code>IOException</code>.
	 * 
	 * @param stream
	 *            the input stream from which the <code>AudioInputStream</code> should be constructed
	 * @return an <code>AudioInputStream</code> object based on the audio file data contained in the input stream.
	 * @throws UnsupportedAudioFileException
	 *             if the stream does not point to valid audio file data recognized by the system
	 * @throws IOException
	 *             if an I/O exception occurs
	 * @see InputStream#markSupported
	 * @see InputStream#mark
	 */
	public static FloatInputStream getAudioInputStream(InputStream stream) throws UnsupportedAudioFileException, IOException {
		Optional<AudioInputStream> opt = reader_list.stream().sequential().map(reader -> {
			try {
				return reader.getAudioInputStream(stream);
			} catch (UnsupportedAudioFileException | IOException e) {
				return null;
			}
		}).filter(o -> o != null).findFirst();

		if (opt.isPresent()) {
			return toFloatInputStream(opt.get());
		} else {
			throw new UnsupportedAudioFileException("could not get audio input stream from input stream");
		}
	}

	/**
	 * Obtains an audio input stream from the URL provided. The URL must point to valid audio file data.
	 * 
	 * @param url
	 *            the URL for which the <code>AudioInputStream</code> should be constructed
	 * @return an <code>AudioInputStream</code> object based on the audio file data pointed to by the URL
	 * @throws UnsupportedAudioFileException
	 *             if the URL does not point to valid audio file data recognized by the system
	 * @throws IOException
	 *             if an I/O exception occurs
	 */
	public static FloatInputStream getAudioInputStream(URL url) throws UnsupportedAudioFileException, IOException {
		Optional<AudioInputStream> opt = reader_list.stream().sequential().map(reader -> {
			try {
				return reader.getAudioInputStream(url);
			} catch (UnsupportedAudioFileException | IOException e) {
				return null;
			}
		}).filter(o -> o != null).findFirst();

		if (opt.isPresent()) {
			return toFloatInputStream(opt.get());
		} else {
			throw new UnsupportedAudioFileException("could not get audio input stream from input URL");
		}
	}

	public static FloatInputStream getAudioInputStream(Path path) throws UnsupportedAudioFileException, IOException {
		Optional<FloatInputStream> opt = reader_list.stream().filter(reader -> reader instanceof PathAwareAudioFileReader).map(reader -> (PathAwareAudioFileReader) reader).sequential().map(reader -> {
			try {
				AudioInputStream ais = reader.getAudioInputStream(path);
				return toFloatInputStream(ais);
			} catch (Exception e) {
				return null;
			}
		}).filter(o -> o != null).findFirst();
		if(opt.isPresent()){
			return opt.get();
		}else{
			return toFloatInputStream(getAudioInputStreamFallback(path));
		}
	}

	private static AudioInputStream getAudioInputStreamFallback(Path path) throws UnsupportedAudioFileException, IOException {
		if (path.toUri().getScheme().equalsIgnoreCase("file")) {
			return getAudioInputStream(path.toFile());
		}
		if (Files.notExists(path) || !Files.isRegularFile(path)) {
			throw new FileNotFoundException();
		}
		if (!Files.isReadable(path)) {
			throw new IOException("Path " + path + " is NOT readable!");
		}
		InputStream in = MyFiles.getEnhancedInputStream(path);
		return getAudioInputStream(in);
	}

	/**
	 * Obtains an audio input stream from the provided <code>File</code>. The <code>File</code> must point to valid audio file data.
	 * 
	 * @param file
	 *            the <code>File</code> for which the <code>AudioInputStream</code> should be constructed
	 * @return an <code>AudioInputStream</code> object based on the audio file data pointed to by the <code>File</code>
	 * @throws UnsupportedAudioFileException
	 *             if the <code>File</code> does not point to valid audio file data recognized by the system
	 * @throws IOException
	 *             if an I/O exception occurs
	 */
	public static FloatInputStream getAudioInputStream(File file) throws UnsupportedAudioFileException, IOException {
		if (!file.exists()) {
			throw new FileNotFoundException();
		} else if (!file.canRead()) {
			throw new IOException();
		}
		Optional<AudioInputStream> opt = reader_list.stream().sequential().map(reader -> {
			try {
				return reader.getAudioInputStream(file);
			} catch (UnsupportedAudioFileException | IOException e) {
				return null;
			}
		}).filter(o -> o != null).findFirst();

		if (opt.isPresent()) {
			return toFloatInputStream(opt.get());
		} else {
			throw new UnsupportedAudioFileException("could not get audio input stream from input file");
		}
	}

	/**
	 * Obtains the file types for which file writing support is provided by the system.
	 * 
	 * @return array of unique file types. If no file types are supported, an array of length 0 is returned.
	 */
	public static Type[] getAudioFileTypes() {
		return writer_list.stream().flatMap(o -> Stream.of(o.getAudioFileTypes())).collect(Collectors.toSet()).toArray(new Type[] {});
	}

	/**
	 * Obtains the file types that the system can write from the audio input stream specified.
	 * 
	 * @param stream
	 *            the audio input stream for which audio file type support is queried
	 * @return array of file types. If no file types are supported, an array of length 0 is returned.
	 */
	public static AudioFileFormat.Type[] getAudioFileTypes(AudioInputStream stream) {
		return writer_list.stream().flatMap(o -> Stream.of(o.getAudioFileTypes(stream))).collect(Collectors.toSet()).toArray(new Type[] {});
	}

	/**
	 * Obtains an audio input stream of the indicated format, by converting the provided audio input stream.
	 * 
	 * @param targetFormat
	 *            the desired audio format after conversion
	 * @param sourceStream
	 *            the stream to be converted
	 * @return an audio input stream of the indicated format
	 * @throws IllegalArgumentException
	 *             if the conversion is not supported #see #getTargetEncodings(AudioFormat)
	 * @see #getTargetFormats(AudioFormat.Encoding, AudioFormat)
	 * @see #isConversionSupported(AudioFormat, AudioFormat)
	 * @see #getAudioInputStream(AudioFormat.Encoding, AudioInputStream)
	 */
	public static FloatInputStream getAudioInputStream(AudioFormat targetFormat, AudioInputStream sourceStream) {
		if (sourceStream.getFormat().matches(targetFormat)) {
			return toFloatInputStream(sourceStream);
		}
		Optional<AudioInputStream> opt = convertor_list.stream().filter(codec -> codec.isConversionSupported(targetFormat, sourceStream.getFormat())).map(codec -> codec.getAudioInputStream(targetFormat, sourceStream)).findAny();
		if (opt.isPresent()) {
			return toFloatInputStream(opt.get());
		}
		// we ran out of options...
		throw new IllegalArgumentException("Unsupported conversion: " + targetFormat + " from " + sourceStream.getFormat());
	}

	/**
	 * Obtains the formats that have a particular encoding and that the system can obtain from a stream of the specified format using the set of installed format converters.
	 * 
	 * @param targetEncoding
	 *            the desired encoding after conversion
	 * @param sourceFormat
	 *            the audio format before conversion
	 * @return array of formats. If no formats of the specified encoding are supported, an array of length 0 is returned.
	 */
	public static AudioFormat[] getTargetFormats(AudioFormat.Encoding targetEncoding, AudioFormat sourceFormat) {
		return convertor_list.stream().flatMap(codec -> Stream.of(codec.getTargetFormats(targetEncoding, sourceFormat))).collect(Collectors.toList()).toArray(new AudioFormat[] {});
	}

	public static FloatOutputStream getAudioOutputStream(OutputStream stream, AudioFileFormat.Type type, AudioFormat format) throws IOException {
		AudioOutputStreamBuilder builder = aos_builders.get(type);
		if (builder == null) {
			throw new IllegalArgumentException("Type: " + type + " not supported!");
		}
		AudioOutputStream aos = builder.setFormat(format).setTarget(new TNonSeekableDataOutputStream(stream)).build();
		return toFloatOutputStream(aos);
	}

	public static FloatOutputStream getAudioOutputStream(Path path, AudioFileFormat.Type type, AudioFormat format) throws IOException {
		if (Files.exists(path)&&!Files.isWritable(path)) {
			throw new IOException("Path " + path + " is NOT writable!");
		}
		AudioOutputStreamBuilder builder = aos_builders.get(type);
		if (builder == null) {
			throw new IllegalArgumentException("Type: " + type + " not supported!");
		}
		AudioOutputStream aos = builder.setFormat(format).setTarget(new RandomPathWriter(path)).build();
		return toFloatOutputStream(aos);
	}

	public static FloatOutputStream getAudioOutputStream(File file, AudioFileFormat.Type type, AudioFormat format) throws IOException {
		if (!file.canWrite() && !file.createNewFile()) {
			throw new IOException("File " + file + " is NOT writable!");
		}
		AudioOutputStreamBuilder builder = aos_builders.get(type);
		if (builder == null) {
			throw new IllegalArgumentException("Type: " + type + " not supported!");
		}
		AudioOutputStream aos = builder.setFormat(format).setTarget(new TSeekableDataOutputStream(file)).build();
		return toFloatOutputStream(aos);
	}

	public static FloatOutputStream getAudioOutputStream(URI uri, AudioFileFormat.Type type, AudioFormat format) throws IOException {
		return getAudioOutputStream(Paths.get(uri), type, format);
	}

	private static final FloatOutputStream toFloatOutputStream(AudioOutputStream aos) {
		if (aos instanceof FloatOutputStream) {
			return (FloatOutputStream) aos;
		} else if (aos instanceof FloatSampleWriter) {
			return new FloatOutputStream((FloatSampleWriter) aos, aos.getFormat(), aos.getLength());
		}
		return new FloatOutputStream(aos);

	}
	
	private static final FloatInputStream toFloatInputStream(AudioInputStream ais) {
		if (ais instanceof FloatInputStream) {
			return (FloatInputStream) ais;
		} else if (ais instanceof FloatSampleInput) {
			return new FloatInputStream((FloatSampleInput) ais, ais.getFormat(), ais.getFrameLength());
		}
		return new FloatInputStream(ais);

	}

	public static final AudioServer getAudioServer(AudioConfiguration config, AudioClient client, String libraryName) throws Exception {
		List<AudioServerProvider> list = new LinkedList<>();
		ServiceLoader.load(AudioServerProvider.class).forEach(list::add);
		if(list.isEmpty()){
			throw new IllegalStateException("No AudioServer library loaded!");
		}
		Optional<AudioServerProvider> opt = list.stream().filter(o -> o.getLibraryName() != null && (libraryName == null || o.getLibraryName().equals(libraryName))).filter(o -> o.isConfigurationSupported(config)).findFirst();
		if (opt.isPresent()) {
			return opt.get().createServer(config, client);
		} else {
			throw new IllegalArgumentException();
		}
	}

	public static final AudioServer getDefaultAudioServer(AudioConfiguration config, AudioClient client) throws Exception {
		return getAudioServer(config, client, null);
	}

	// METHODS FOR INTERNAL IMPLEMENTATION USE

	private static final List<AudioFileReader> reader_list = Collections.synchronizedList(new LinkedList<>());
	private static final List<AudioFileWriter> writer_list = Collections.synchronizedList(new LinkedList<>());
	private static final List<FormatConversionProvider> convertor_list = Collections.synchronizedList(new LinkedList<>());
	private static final NavigableMap<AudioFileFormat.Type, AudioOutputStreamBuilder> aos_builders = Collections.synchronizedNavigableMap(new TreeMap<>((o1, o2) -> String.valueOf(o1).compareTo(String.valueOf(o2))));

	public static boolean addReader(AudioFileReader e) {
		return reader_list.add(e);
	}

	public static boolean addWriter(AudioFileWriter e) {
		return writer_list.add(e);
	}

	public static void putAOSBuilder(AudioFileFormat.Type type, AudioOutputStreamBuilder builder) {
		Objects.requireNonNull(type);
		Objects.requireNonNull(builder);
		aos_builders.put(type, builder);
	}

	static {
		ServiceLoader.load(AudioFileReader.class).forEach(reader_list::add);
		ServiceLoader.load(AudioFileWriter.class).forEach(writer_list::add);
		ServiceLoader.load(FormatConversionProvider.class).forEach(convertor_list::add);
		ServiceLoader.load(AudioOutputStreamBuilder.class).forEach(o -> aos_builders.put(o.getType(), o));
	}

}
